ClickHouse
20.7.3.14
20.5 final
20.6.3 explain
1. 介绍
- 俄罗斯Yandex开发。
- 列式存储数据库(相较于MySQL等行式存储数据库区别)。
- 列式存储是指数据在磁盘中以列的方式进行存储。相较之下查询相同列的数据的时候比较快,查询行的数据比较慢,可能需要遍历整张表,但是修改的代价高。列式存储对聚合、计数、求和等操作速度远快于行式存储,更适合进行查询分析的场景。某一列的数据类型是相同的,可以进行数据压缩。
- 行式存储查询行的数据比较快,但是查询某一列的数据就比较慢,可能要遍历整张表,但是修改的代价低。更适合进行联机修改的场景。
- 类比成数据结构中的线性表的优缺点。
- 用途:在线分析处理查询(OLAP),能够使用SQL查询实时生成分析数据报告。
- C++编写。
- 可以使用SQL语句进行操作。
- 引擎分类:合并树(Merge Tree)、日志、接口、其他四大类20多种。
- 高吞吐写入能力。采用LSM Tree结构,数据写入是顺序append写,写入后不可更改。数据写入定期后台Compaction,多个段merge sort后顺序写回磁盘。
- 数据分区和线程级并行,单条Query就能利用整机所有CPU,吃CPU,对于高qps的查询业务不是强项。
- 适合大量数据的宽表场景。
- 避免join操作(把右表加载到内存,再和左表匹配)。
2. 安装
关闭防火墙或者开放端口。
取消Linux上的文件数及用户最多进程数的限制。
$ ulimit -a 主要修改的是这两个 open files max user processes
$ sudo vim /etc/security/limits.conf * soft nofile 65536 * hard nofile 65536 * soft nproc 131072 * hard nproc 131072 * 限定域为所有用户 soft软限制,日常状态/hard硬限制,最大上限 $ sudo vim /etc/security/limits.d/20-nproc.conf * soft nofile 65536 * hard nofile 65536 * soft nproc 131072 * hard nproc 131072
重新登录。
安装依赖。
sudo yum install -y libtool sudo yum install -y *unixODBC*
CentOS取消SELINUX
SE:security enforce.
$ sudo vim /etc/selinux/config SELINUX=disabled
重启。
安装。
sudo yum install clickhouse-server clickhouse-client vim /etc/clickhouse-server/config.xml # 把 <listen_host>::</listen_host> 的注释打开,让CH支持远程访问。
重启服务。
3. 数据类型
3.1. 整型
固定长度的整型,包括有符号整型或无符号整型。 整型范围(-2n-1~2n-1-1):
Int8 - [-128 : 127]
Int16 - [-32768 : 32767]
Int32 - [-2147483648 : 2147483647]
Int64 - [-9223372036854775808 : 9223372036854775807]
无符号整型范围(0~2n-1):
UInt8 - [0 : 255]
UInt16 - [0 : 65535]
UInt32 - [0 : 4294967295]
UInt64 - [0 : 18446744073709551615]
使用场景: 个数、数量、也可以存储型 id。
Int8 - byte
Int16 - short
Int32 - int
Int64 - long
3.2. 浮点型
Float32 - float
Float64 – double
浮点型进行计算时可能引起四舍五入的误差。
使用场景:一般数据值比较小,不涉及大量的统计计算,精度要求不高的时候。比如 保存商品的重量。
3.3. 布尔型
没有单独的类型来存储布尔值。可以使用 UInt8 类型,取值限制为 0 或 1。
3.4. Decimal 型
有符号的浮点数,可在加、减和乘法运算过程中保持精度。对于除法,最低有效数字会 被丢弃(不舍入)。
有三种声明:
Decimal32(s),相当于Decimal(9-s,s),有效位数为1~9
Decimal64(s),相当于Decimal(18-s,s),有效位数为1~18
Decimal128(s),相当于Decimal(38-s,s),有效位数为1~38
s 标识小数位 使用场景:一般金额字段、汇率、利率等字段为了保证小数点精度,都使用 Decimal进行存储。
3.5. 字符串
String
字符串可以任意长度的。它可以包含任意的字节集,包含空字节。
FixedString(N)
固定长度 N 的字符串,N 必须是严格的正自然数。当服务端读取长度小于 N 的字符
串时候,通过在字符串末尾添加空字节来达到 N 字节长度。 当服务端读取长度大于 N 的 字符串时候,将返回错误消息。
使用场景:称、文字描述、字符型编码。 固定长度的可以保存一些定长的内容,比 如一些编码,性别等但是考虑到一定的变化风险,带来收益不够明显,所以定长字符串使用 意义有限。
3.6. 枚举类型
包括 Enum8 和 Enum16 类型。Enum 保存 'string'= integer 的对应关系。
Enum8 用 'String'= Int8 对描述。
Enum16 用 'String'= Int16 对描述。
CREATE TABLE t_enum
(
x Enum8('hello' = 1, 'world' = 2)
)
ENGINE = TinyLog;
-- 此时存储可以使用key存储,也可以使用value进行存储。select时,默认显示key,可以使用Cast(colName, toTypeName(value))显示value值。
使用场景:对一些状态、类型的字段算是一种空间优化,也算是一种数据约束。但是实际使用中往往因为一些数据内容的变化增加一定的维护成本,甚至是数据丢失问题。所以谨慎使用。
3.7. 时间类型
目前 ClickHouse 有三种时间类型
Date接受年-月-日的字符串比如‘2019-12-16’
Datetime 接受年-月-日 时:分:秒的字符串比如 ‘2019-12-16 20:50:10’
Datetime64 接受年-月-日 时:分:秒.亚秒的字符串比如‘2019-12-16 20:50:10.66’
日期类型,用两个字节存储,表示从 1970-01-01 (无符号) 到当前的日期值。
3.8. 数组
Array(T):由 T 类型元素组成的数组。
T 可以是任意类型,包含数组类型。 但不推荐使用多维数组,ClickHouse 对多维数组的支持有限。例如,不能在 MergeTree 表中存储多维数组。
创建数组的方式:
Array(1,2)
[1,2]
4. 表引擎
4.1. TinyLog
不支持索引,没有并发控制。一般保存少量数据的小表, 生产环境上作用有限。
create table t_tinylog (id String, name String) engine=TinyLog;
4.2. Memory
内存引擎,数据以未压缩的原始形式直接保存在内存当中,服务器重启数据就会消失。读写操作不会相互阻塞,不支持索引。
4.3. MergeTree
支持索引和分区。
CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1] [TTL expr1],
name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2] [TTL expr2],
...
INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1,
INDEX index_name2 expr2 TYPE type2(...) GRANULARITY value2
) ENGINE = MergeTree()
ORDER BY expr
[PARTITION BY expr]
[PRIMARY KEY expr]
[SAMPLE BY expr]
[TTL expr [DELETE|TO DISK 'xxx'|TO VOLUME 'xxx'], ...]
[SETTINGS name=value, ...]
PARTITION BY分区
降低扫描的范围,优化查询速度。
可选项,如果不选默认使用一个all分区。
分区后,面对涉及跨分区的查询统计,ClickHouse 会以分区为单位并行处理。
数据写入与分区合并:任何一个批次的数据写入都会产生一个临时分区,不会纳入任何一个已有的分区。写入后的某个时刻(大概 10-15 分钟后),ClickHouse 会自动执行合并操作(等不及也可以手动通过 optimize 执行),把临时分区的数据,合并到已有分区中。
OPTIMIZE TABLE table_name PARTITION partition_name FINAL;
透过自带的client查询的时候,数据会显示分区的详情,透过其他工具则不会有。
数据在分区内有序。
PRIMARY KEY主键
ClickHouse中的主键只有一级索引,并没有唯一性约束。
在where语句中根据主键查询信息,会对主键进行某种形式的二分查找,从而定位到index granularity信息,避免全盘扫描。
index granularity:索引粒度,指在稀疏索引中两个相邻索引对应数据的间隔。ClickHouse 中的 MergeTree 默认是 8192。
要求:主键必须是 order by 字段的前缀字段。
ORDER BY 排序字段【必选】
此字段设定了分区内的数据按照哪些字段顺序进行有序保存,是 MergeTree 中唯一一个必填项。
二级索引
INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1
GRANULARITY value1 是设定二级索引对于一级索引粒度的粒度。
create table t_order_mt2(
id UInt32,
sku_id String,
total_amount Decimal(16,2), create_time Datetime,
INDEX a total_amount TYPE minmax GRANULARITY 5
) engine =MergeTree
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id, sku_id);
TTL
TTL 即 Time To Live,MergeTree 提供了可以管理数据表或者列的生命周期的功能。
列级别TTL
name1 [type1] [TTL expr1],
create table t_order_mt3(
id UInt32,
sku_id String,
total_amount Decimal(16,2) TTL create_time+interval 10 SECOND,
create_time Datetime
) engine =MergeTree
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id, sku_id);
-- total_amount Decimal(16,2) TTL create_time+interval 10 SECOND,
-- 在create_time时间的10秒之后total_amount字段变成默认值
需要合并后才会看到效果。
表级别TTL
alter table table_name MODIFY TTL create_time + INTERVAL 10 SECOND;
-- 表数据会在 create_time 之后 10 秒消失。
涉及判断的字段必须是 Date 或者 Datetime 类型,推荐使用分区的日期字段。
4.4. ReplacingMergeTree
存储特性完全继承 MergeTree,只是 多了一个去重的功能。
会根据order by字段去重,并不是根据primary key字段去重。
去重的过程只会在合并的过程中出现,合并会在后台不定时进行,无法进行预知,所以有些数据可能还未被处理。
如果表经过了分区,则只会在分区内进行去重。
适用于在后台清理重复的数据以节省空间,不能保证没有重复的数据出现。
CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1],
name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2],
...
) ENGINE = ReplacingMergeTree([ver])
[PARTITION BY expr]
[ORDER BY expr]
[SAMPLE BY expr]
[SETTINGS name=value, ...]
ENGINE = ReplacingMergeTree([ver])
填入的为版本字段,重复的数据保留版本值最大的。
如果不填ver或者ver相同,则默认按照插入顺序保留最后一条。
4.5. SummingMergeTree
对于不查询明细,只关心以维度进行汇总聚合结果的场景。如果只使用普通的 MergeTree 的话,无论是存储空间的开销,还是查询时临时聚合的开销都比较大。
ClickHouse 为了这种场景,提供了一种能够“预聚合”的引擎 SummingMergeTree。
create table t_order_smt(
id UInt32,
sku_id String,
total_amount Decimal(16,2) ,
create_time Datetime
) engine =SummingMergeTree(total_amount)
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id,sku_id );
engine =SummingMergeTree(total_amount)
中,total_amount
列作为汇总数据列。
可以填写多列必须数字列,如果不填,以所有非维度列且为数字列的字段为汇总数据列。
以 order by 的列为准,作为维度列。
其他的列按照插入的顺序保留最开始插入的一行。
聚合只会聚合同一个分区内的数据。
只有在同一个批次插入或者分片合并的时候才会进行聚合。如果要是获取汇总值,还是需要使用 sum 进行聚合。
5. SQL
5.1. Insert
-- 插入字段
insert into [table_name] values(...),(....);
-- 插入表
insert into [table_name] select a,b,c from [table_name_2];
5.2. Alter
Delete和Update都是Alter的一种。这类被称作可变查询(Mutation 查询)。
Mutation 查询是一种很重的操作(每次修改或者删除都会导致放弃目标数据的原有分区,重建新分区),而且不支持事务。应该尽量做批量变更,不要作频繁的小数据操作。
ALTER TABLE [db].name ADD INDEX name expression TYPE type GRANULARITY value [FIRST|AFTER name] - 向表元数据添加索引描述。
ALTER TABLE [db].name DROP INDEX name - 从表元数据中删除索引描述并从磁盘中删除索引文件。
-- 前两个命令是轻量级的,它们只更改元数据或删除文件。
ALTER TABLE [db.]table MATERIALIZE INDEX name IN PARTITION partition_name - 查询在分区partition_name中重建二级索引name。 操作为mutation.
ALTER TABLE [db].name DELETE WHERE column_name = value;
ALTER TABLE [db].name UPDATE column_name1 = value1 WHERE column_name2 = value2;
alter table tableName add column newcolname String after col1;
alter table tableName modify column colname String;
alter table tableName drop column colname;
实现高性能update或delete的思路:
create table A
(
a xxx,
b xxx,
c xxx,
_sign UInt8,
_version UInt32
)
==> 更新 : 插入一条新的数据, _version + 1 =》 查询的时候加上一个过滤条件, where version最大
==> 删除: _sign, 0表示未删除,1表示已删除, 同时 version + 1 =》 查询的时候加上一个过滤条件, where _sign=0 and version最大
==> 时间久了,数据膨胀 ==》 类似合并机制,怎么把过期数据清除掉
5.3. 查询
支持子查询
支持 CTE(Common Table Expression 公用表表达式 with 子句)
支持各种JOIN,但是JOIN操作无法使用缓存,所以即使是两次相同的JOIN语句,
ClickHouse 也会视为两条新 SQL(少用)
窗口函数(官方正在测试中...)
不支持自定义函数
GROUP BY 操作增加了 with rollup\with cube\with total 用来计算小计和总计。
with rollup\with cube\with total 维度是a,b rollup:上卷 从右至左去掉维度进行小计 group by a,b group by a 空 cube:多维分析 从右至左去掉维度进行小计,再从左至右去掉维度进行小计 group by a,b group by a group by b 空 total:总计 只计算合计 group by a,b 空
select id , sku_id,sum(total_amount) from t_order_mt group by id,sku_id with rollup; select id , sku_id,sum(total_amount) from t_order_mt group by id,sku_id with cube; select id , sku_id,sum(total_amount) from t_order_mt group by id,sku_id with totals;
5.4. 函数
5.5. 导出数据
clickhouse-client --query "select * from t_order_mt where
create_time='2020-06-01 12:00:00'" --format CSVWithNames>
/opt/module/data/rs1.csv
更多格式参照:输入/输出格式
6. EXPLAIN
仅支持20.6以上版本。
查看 PLAIN(默认)
explain plan select arrayJoin([1,2,3,null,null]); explain select database,table,count(1) cnt from system.parts where database in ('datasets','system') group by database,table order by database,cnt desc limit 2 by database; -- 打开全部的参数的执行计划 EXPLAIN header=1, actions=1,description=1 SELECT number from system.numbers limit 10;
AST树
EXPLAIN AST SELECT number from system.numbers limit 10;
SYNTAX语法优化(常用)
-- 开启三元运算符优化 SET optimize_if_chain_to_multiif = 1; -- EXPLAIN SYNTAX + SQL EXPLAIN SYNTAX SELECT number = 1 ? 'hello' : (number = 2 ? 'world' : '2021') FROM numbers(10); //返回优化后的语句 SELECT multiIf(number = 1, \'hello\', number = 2, \'world\', \'2021\') FROM numbers(10)
查看PIPELINE(线程情况)
```sql EXPLAIN PIPELINE SELECT sum(number) FROM numbers_mt(100000) GROUP BY number % 20;
-- 打开其他参数 EXPLAIN PIPELINE header=1,graph=1 SELECT sum(number) FROM numbers_mt(10000) GROUP BY number%20;
### 老版本查看执行计划
```sql
clickhouse-client -h 主机名 --send_logs_level=trace <<< "SQL语句" > /dev/null
其中,send_logs_level 参数指定日志等级为 trace,<<<将 SQL 语句重定向至 clickhouse-client 进行查询,> /dev/null 将查询结果重定向到空设备吞掉,以便观察日志。
7. 副本
副本的目的主要是保障数据的高可用性,即使一台 ClickHouse 节点宕机,那么也可以从 其他服务器获得相同的数据。
副本只支持合并树家族引擎,且只针对表。
create table t_order_rep2 (
id UInt32,
sku_id String,
total_amount Decimal(16,2), create_time Datetime
) engine =ReplicatedMergeTree('/clickhouse/table/01/t_order_rep','rep_102')
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id,sku_id);
CK采用的是多主模式,即没有主从之分。随意的一个节点修改另一个节点也会进行同步。
7.1. 步骤
启动ZooKeeper服务
在CK中添加ZK的配置
有两种方式:
在原有的配置文件中进行配置
config.xml
<?xml version="1.0"?> <yandex> <zookeeper-servers> <node index="1"> <host>hadoop102</host> <port>2181</port> </node> <node index="2"> <host>hadoop103</host> <port>2181</port> </node> <node index="3"> <host>hadoop104</host> <port>2181</port> </node> </zookeeper-servers> </yandex>
在外部配置文件中配置后在原有的配置中进行导入
config.d/metrika.xml:
<?xml version="1.0"?> <yandex> <zookeeper-servers> <node index="1"> <host>hadoop102</host> <port>2181</port> </node> <node index="2"> <host>hadoop103</host> <port>2181</port> </node> <node index="3"> <host>hadoop104</host> <port>2181</port> </node> </zookeeper-servers> </yandex>
config.xml
<include_from>/etc/clickhouse-server/config.d/metrika.xml</include_from> <!-- 外部导入需要添加下面的属性 --> <zookeeper incl="zookeeper-servers" optional="true" />
将上述修改同步到多个主机上,并重启ZK和CK。
副本只同步表内容,表需要进行分别创建。
create table t_order_rep2 ( id UInt32, sku_id String, total_amount Decimal(16,2), create_time Datetime ) engine =ReplicatedMergeTree('/clickhouse/table/01/t_order_rep','rep_102') partition by toYYYYMMDD(create_time) primary key (id) order by (id,sku_id);
其中,ReplicatedMergeTree('/clickhouse/table/01/t_order_rep','rep_102')
第一个参数是分片的zk_path一般按照:/clickhouse/table/{shard}/{table_name} 的格式写,如果只有一个分片就写 01 即可。
第二个参数是副本名称,相同的分片副本名称不能相同,所以在不同的节点上都应该不同。
8. 分片集群
副本虽然能够提高数据的可用性,降低丢失风险,但是每台服务器实际上必须容纳全量数据,对数据的横向扩容没有解决。
要解决数据水平切分的问题,需要引入分片的概念。通过分片把一份完整的数据进行切分,不同的分片分布到不同的节点上,再通过 Distributed 表引擎把数据拼接起来一同使用。
Distributed 表引擎本身不存储数据,通过分布式逻辑表来写入、分发、路由来操作多台节点不同分片的分布式数据。
ClickHouse 的集群是表级别的,实际企业中,大部分做了高可用,但是没有用分片,避免降低查询性能以及操作集群的复杂性。
8.1. 集群写入和读取流程流程(3分片2副本为例)
8.2. 配置步骤
有两种方式,内部配置和上面副本的内部配置相似。
config.d/metrika.xml
<remote_servers> <!-- 集群名称,可以自己设置 --> <gmall_cluster> <!--集群的第一个分片--> <shard> <internal_replication>true</internal_replication> <!--该分片的第一个副本--> <replica> <host>hadoop102</host> <port>9000</port> </replica> <!--该分片的第二个副本--> <replica> <host>hadoop103</host> <port>9000</port> </replica> </shard> <!--集群的第二个分片--> <shard> <internal_replication>true</internal_replication> <!--该分片的第一个副本--> <replica> <host>hadoop104</host> <port>9000</port> </replica> </shard> </gmall_cluster> </remote_servers> <zookeeper-servers> <node index="1"> <host>hadoop102</host> <port>2181</port> </node> <node index="2"> <host>hadoop103</host> <port>2181</port> </node> <node index="3"> <host>hadoop104</host> <port>2181</port> </node> </zookeeper-servers> <!-- 宏指令 --> <macros> <!-- 存放的分片数 不同机器放的分片数不一样--> <shard>01</shard> <!--不同机器放的副本数不一样--> <!-- rep_1_1 副本名 = 名字_分片数_副本数 --> <replica>rep_1_1</replica> </macros>
config.xml
<include_from>/etc/clickhouse-server/config.d/metrika.xml</include_from> <!-- 外部导入需要添加下面的属性 --> <zookeeper incl="zookeeper-servers" optional="true" />
同步上述两个文件到其他机器并修改macros中的参数,然后重启全部CK。
在集群上创建本地表
create table st_order_mt on cluster gmall_cluster ( -- gmall_cluster为配置文件中指定的集群名字 id UInt32, sku_id String, total_amount Decimal(16,2), create_time Datetime ) engine =ReplicatedMergeTree('/clickhouse/tables/{shard}/st_order_mt','{replica}') -- 此处引擎要用分布式的合并树家族的引擎之一,(zk_path){shard}和(副本名){replica}会自动读取<macros>中的变量。st_order_mt此处和创建的表名一致 partition by toYYYYMMDD(create_time) primary key (id) order by (id,sku_id);
创建分布式表
create table st_order_mt_all2 on cluster gmall_cluster -- -- gmall_cluster为配置文件中指定的集群名字 ( id UInt32, sku_id String, total_amount Decimal(16,2), create_time Datetime )engine = Distributed(gmall_cluster,default, st_order_mt,hiveHash(sku_id));
Distributed(集群名称,库名,本地表名,分片键);
使用分布式表进行数据操作
insert into st_order_mt_all2 values (201,'sku_001',1000.00,'2020-06-01 12:00:00') , (202,'sku_002',2000.00,'2020-06-01 12:00:00'), (203,'sku_004',2500.00,'2020-06-01 12:00:00'), (204,'sku_002',2000.00,'2020-06-01 12:00:00'), (205,'sku_003',600.00,'2020-06-02 12:00:00');
查询的时候使用分布式表
SELECT * FROM st_order_mt_all;
注意:
查询本地表只会查询到本机存储部分的信息。
查询到的结果会分区、分片显示(clickhouse-client)。
相同分片下的不同副本存储的信息相同。
不同分片存储的信息不同。
9. 优化
9.1. 表优化
时间字段的类型
建表时能用数值型或日期时间型表示的字段就不要用字符串。
ClickHouse 底层将 DateTime 存储为时间戳 Long 类型,但不建议存储 Long 类型, 因为 DateTime 不需要经过函数转换处理,执行效率高、可读性好。
-
Nullable类型几乎总是会拖累性能,因为存储Nullable列时需要创建一个 额外的文件(column_name.null.bin)来存储 NULL 的标记,并且 Nullable 列无法被索引。
除非极特殊情况,应直 接使用字段默认值表示空,或者自行指定一个在业务中无意义的值(例如用-1 表示没有商品 ID)。
分区和索引
一般选择按天分区,也可以指定为 Tuple(), 以单表一亿数据为例,分区大小控制在 10-30 个为最佳。
必须指定索引列,ClickHouse 中的索引列即排序列,通过 order by 指定。
通常需要满足高级列在前、查询频率大的在前原则。
基数特别大的不适合做索引列, 如用户表的 userid 字段;通常筛选后的数据满足在百万以内为最佳。
PARTITION BY toYYYYMM(EventDate) ORDER BY (CounterID, EventDate, intHash32(UserID)) PARTITION BY toYYYYMM(StartDate) ORDER BY (CounterID, StartDate, intHash32(UserID), VisitID)
表参数
Index_granularity 是用来控制索引粒度的,默认是 8192,如非必须不建议调整。
如果表中不是必须保留全量历史数据,建议指定 TTL(生存时间值),可以免去手动过期历史数据的麻烦,TTL 也可以通过 alter table 语句随时修改。
写入和删除优化
尽量不要执行单条或小批量删除和插入操作,这样会产生小分区文件,给后台 Merge 任务带来巨大压力。
不要一次写入太多分区,或数据写入太快,数据写入太快会导致 Merge 速度跟不上而报错,一般建议每秒钟发起 2-3 次写入操作,每次操作写入 2w~5w 条数据(依服务器性能而定
写入过快报错,报错信息:
1. Code: 252, e.displayText() = DB::Exception: Too many parts(304). Merges are processing significantly slower than inserts 2. Code: 241, e.displayText() = DB::Exception: Memory limit (for query) exceeded:would use 9.37 GiB (attempt to allocate chunk of 301989888 bytes), maximum: 9.31 GiB
处理方式: “ Too many parts 处理 ” :使用 WAL 预写日志,提高写入性能。in_memory_parts_enable_wal 默认为 true
在服务器内存充裕的情况下增加内存配额,一般通过 max_memory_usage 来实现在服务器内存不充裕的情况下,建议将超出部分内容分配到系统硬盘上,但会降低执行速度,一般通过 max_bytes_before_external_group_by、max_bytes_before_external_sort 参数来实现。
9.2. 配置优化
配置项主要在 config.xml 或 users.xml 中, 基本上都在 users.xml 里。
CPU资源
配置 | 描述 |
---|---|
background_pool_size | 后台线程池的大小,merge 线程就是在该线程池中执行,该线程池不仅仅是给 merge 线程用的,默认值 16,允许的前提下建议改成 c pu 个数的 2 倍(线程数)。 |
background_schedule_pool_size | 执行后台任务(复制表、Kafka 流、DNS 缓存更新)的线程数。默 认 128,建议改成 cpu 个数的 2 倍(线程数)。 |
backgrounddistributed_schedule pool_size | 设置为分布式发送执行后台任务的线程数,默认 16,建议改成 cpu 个数的 2 倍(线程数)。 |
max_concurrent_queries | 最大并发处理的请求数(包含 select,insert 等),默认值 100,推荐 150(不够再加)~300。 |
max_threads | 设置单个查询所能使用的最大 cpu 个数,默认是 cpu 核数。 |
内存资源
配置 | 描述 |
---|---|
max_memory_usage | 此参数在 users.xml 中,表示单次 Query 占用内存最大值,该值可 以设置的比较大,这样可以提升集群查询的上限。 保留一点给 OS,比如 128G 内存的机器,设置为 100GB。 |
maxbytes_before_external_group by | 一般按照 max_memory_usage 的一半设置内存,当 group 使用内 存超过阈值后会刷新到磁盘进行。 因为 clickhouse 聚合分两个阶段:查询并及建立中间数据、合并中间数据,结合上一项,建议 50GB。 |
max_bytes_before_external_sort | 当 order by 已使用 max_bytes_before_external_sort 内存就进行 溢写磁盘(基于磁盘排序),如果不设置该值,那么当内存不够时直接 抛错,设置了该值 order by 可以正常完成,但是速度相对存内存来 说肯定要慢点(实测慢的非常多,无法接受)。 |
max_table_size_to_drop | 此参数在 config.xml 中,应用于需要删除表或分区的情况,默认是 50GB,意思是如果删除 50GB 以上的分区表会失败。建议修改为 0, 这样不管多大的分区表都可以删除。 |
存储
ClickHouse 不支持设置多数据目录,为了提升数据 IO 性能,可以挂载虚拟卷组,一个卷组绑定多块物理磁盘提升读写性能。
9.3. 语法优化
count函数优化
如果使用的是 count() 或者 count(*),且没有 where 条件,则会直接使用 system.tables 的 total_rows(counts.txt)
如果 count 具体的列字段,则不会使用此项优化。
消除子查询重复字段
表的字段相同,比如
select a, a
会被优化成select a
。也会删除重复的order by key,limit by key, using key,只要判断是同一个表中的同一个字段就会被优化,即使用了别名也没用。
谓词下推
当 group by 有 having 子句,但是没有 with cube、with rollup 或者 with totals 修饰的时候,having 过滤会下推到 where 提前过滤。例如下面的查询,HAVING name 变成了WHERE name,在 group by 之前过滤。子查询也支持谓词下推。
EXPLAIN SYNTAX SELECT UserID FROM hits_v1 GROUP BY UserID HAVING UserID = '8585742290196126178'; -- 返回优化语句 SELECT UserID FROM hits_v1 WHERE UserID = \'8585742290196126178\' GROUP BY UserID
EXPLAIN SYNTAX SELECT * FROM ( SELECT * FROM ( SELECT UserID FROM visits_v1 ) UNION ALL SELECT * FROM ( SELECT UserID FROM visits_v1 ) ) WHERE UserID = '8585742290196126178' -- 返回优化语句 SELECT UserID FROM ( SELECT UserID FROM ( SELECT UserID FROM visits_v1 WHERE UserID = \ '8585742290196126178\' ) WHERE UserID = \'8585742290196126178\' UNION ALL SELECT UserID FROM ( SELECT UserID FROM visits_v1 WHERE UserID = \'8585742290196126178\' ) WHERE UserID = \'8585742290196126178\' ) WHERE UserID = \'8585742290196126178\'
聚合计算外推
sum(a * 2)
会被优化成sum(a) * 2
聚合函数消除
EXPLAIN SYNTAX SELECT sum(UserID * 2), max(VisitID), max(UserID) FROM visits_v1 GROUP BY UserID -- 返回优化语句 SELECT sum(UserID) * 2, max(VisitID), UserID -- 此时max(UserID)无意义,被优化 FROM visits_v1 GROUP BY UserID
标量替换
如果子查询只返回一行数据,在被引用的时候用标量替换。
三元运算优化
如果开启了 optimize_if_chain_to_multiif 参数,三元运算符会被替换成 multiIf 函数。
9.4. 查询优化
单表查询
PREWHERE替代WHERE
Prewhere 和 where 语句的作用相同,用来过滤数据。不同之处在于 prewhere 只支持 *MergeTree 族系列引擎的表。
Prewhere会读取指定的列数据,按照条件进行过滤后再读取select需要的列字段进行其他属性的补全。
当查询列明显多于筛选列时使用 Prewhere可十倍提升查询性能,Prewhere 会自动优化执行过滤阶段的数据读取方式,降低 io 操作。
在某些场合下,prewhere 语句比 where 语句处理的数据量更少性能更高。
-- 开关where自动转where(默认开) set optimize_move_to_prewhere=0; -- 关 set optimize_move_to_prewhere=0; -- 开
默认情况,我们不会关闭 where 自动优化成 prewhere。
但是某些场景即使开启优化,也不会自动转换成 prewhere,需要手动指定 prewhere(即把where关键字换成prewhere即可)。
不会自动转换成prewhere的情况:
- 使用常量表达式
- 默认值为alias类型的字段
- 包含了arrayJOIN、globalIn、globalNotIn或者indexHint的查询
- select查询的列字段和where的谓词相同
- 使用了主键字段
数据采样
SELECT Title,count(*) AS PageViews FROM hits_v1 SAMPLE 0.1 #代表采样 10%的数据,也可以是具体的条数 WHERE CounterID =57 GROUP BY Title ORDER BY PageViews DESC LIMIT 1000
采样修饰符只有在 MergeTree engine 表中才有效,且在创建表时需要指定采样策略。
列裁剪和分区裁剪
- 数据量太大时应避免使用 select * 操作(宽表),字段越少,消耗的 io 资源越少,性能就会越高。
- 分区裁剪就是只读取需要的分区,在过滤条件中指定。
where EventDate='2014-03-23';
orderby 结合 where、limit
避免全表排序,可能会导致资源不足或者检索过长。千万以上数据集进行 order by 查询时需要搭配 where 条件和 limit 语句一起使用。
SELECT UserID,Age FROM hits_v1 ORDER BY Age DESC LIMIT 1000
避免构建虚列,可以在前端进行处理或者在表中构造实际字段进行额外存储。
uniqCombined 替代 distinct
性能可提升 10 倍以上,uniqCombined 底层采用类似 HyperLogLog 算法实现,能接收 2% 左右的数据误差(对误差要求比较严格的还是用distinct),可直接使用这种去重方式提升查询性能。Count(distinct)会使用 uniqExact 精确去重。
不建议在千万级不同数据上执行 distinct 去重查询,改为近似去重 uniqCombined。
使用物化视图
其他注意事项
查询熔断 为了避免因个别慢查询引起的服务雪崩的问题,除了可以为单个查询设置超时以外,还 可以配置周期熔断,在一个查询周期内,如果用户频繁进行慢查询操作超出规定阈值后将无 法继续进行查询操作。
关闭虚拟内存
物理内存和虚拟内存的数据交换,会导致查询变慢,资源允许的情况下关闭虚拟内存。
配置 join_use_nulls 为每一个账户添加 join_use_nulls 配置,左表中的一条记录在右表中不存在,右表的相 应字段会返回该字段相应数据类型的默认值,而不是标准 SQL 中的 Null 值。
批量写入时先排序
批量写入数据时,必须控制每个批次的数据中涉及到的分区的数量,在写入之前最好对 需要导入的数据进行排序。无序的数据或者涉及的分区太多,会导致 ClickHouse 无法及时对 新导入的数据进行合并,从而影响查询性能。
关注 CPU cpu 一般在 50%左右会出现查询波动,达到 70%会出现大范围的查询超时,cpu 是最关 键的指标,要非常关注。
多表关联
多表关联应该尽量减少使用join,CK的join原理:将右表加载到内存,再和左表进行匹配。
如果语句能够使用in替换,则使用in。
满足小表在右的原则(这里和MySQL相反),减小内存的使用。
限制条件尽量往上提,能先过滤先过滤,减小数据的比较。(谓词下推)
这里有个小点,having是根据结果进行过滤,可以考虑能否用where替代(先过滤再进行比较),进而可以再考虑是否可以使用prewhere(读取指定的列数据,按照条件进行过滤后再读取select需要的列字段进行其他属性的补全)。
使用字典表
分布式表中要使用GLOBAL关键字
两张分布式表上的 IN 和 JOIN 之前必须加上 GLOBAL 关键字,右表只会在接收查询请求的那个节点查询一次,并将其分发到其他节点上。如果不加 GLOBAL 关键字的话,每个节点都会单独发起一次对右表的查询,而右表又是分布式表,就导致右表一共会被查询 N2(N 是该分布式表的分片数量),这就是查询放大,会带来很大开销。
10. 数据一致性
注意点:ReplacingMergeTree不能保证数据一定没有重复(未merge等情况),只能保证数据的最终一致性。
保持数据一致性的解决方案:
手动去重
OPTIMIZE TABLE table_name PARTITION partition_name FINAL;
生产环境不推荐。
使用SQL实现去重(加标记字段)
-- create_time 是版本号字段,每组数据中 create_time 最大的一行表示最新的数据; deleted 是自 定的一个标记位,比如 0 代表未删除,1 代表删除数据。 SELECT user_id , argMax(score, create_time) AS score, argMax(deleted, create_time) AS deleted, max(create_time) AS ctime FROM test_a GROUP BY user_id HAVING deleted = 0; -- argMax(field1,field2):按照 field2 的最大值取 field1 的值。 -- 当我们更新数据时,会写入一行新的数据,例如上面语句中,通过查询最大的create_time 得到修改后的 score 字段值。 CREATE VIEW view_test_a AS SELECT user_id , argMax(score, create_time) AS score, argMax(deleted, create_time) AS deleted, max(create_time) AS ctime FROM test_a GROUP BY user_id HAVING deleted = 0; SELECT * FROM view_test_a WHERE user_id = 0; -- 透过创建视图,可以将筛选的操作保存起来。
在这种情况下,进行删除或者更新,本质上还是进行数据的插入。最终透过条件筛选得出最新的未被标记为删除的数据。
数据并没有被真正的删除,而是被过滤掉了。在一些合适的场景下,可以结合表级别的 TTL 最终将物理数据删除。
使用FINAL。
老版本的FINAL是单线程。
新版本的FINAL是多线程,但是读取part的过程是串行的。
SELECT * FROM visits_v1 FINAL WHERE StartDate = '2014-03-17' LIMIT 100
对数据重复要求比较低,并且数据误差不大,不管。
11. 物化视图
物化视图和普通视图的区别:普通视图不保存数据,保存的仅仅是查询语句。物化视图则是把查询的结果根据相应的引擎存入到了磁盘 或内存中。
优点:查询速度快,要是把物化视图这些规则全部写好,它比原数据查询快了很多,总的行数少了,因为都预计算好了。
缺点:1. 它的本质是一个流式数据的使用场景,是累加式的技术,所以要用历史数据做去重、去核这样的分析,在物化视图里面是不太好用的。在某些场景的使用也是有限的。而且 如果一张表加了好多物化视图,在写这张表的时候,就会消耗很多机器的资源,比如数据带宽占满、存储一下子增加了很多。2. 物化视图的 alter 操作有些限制,操作起来不大方便。3. 物化视图的 alter 操作有些限制,操作起来不大方便。物化视图不支持同步删除,若源表的数据不存在(删除了)则物化视图的数据仍然保留。
11.1. 语法
CREATE [MATERIALIZED] VIEW [IF NOT EXISTS] -- 视图名
[db.]table_name -- 原表
-- 1. 默认创建一个隐藏的目标表( .inner.物化视图名)来保存视图数据。使用 TO 表名,保存到一张显式的表。
-- 2. 若物化视图的定义使用了 TO [db.]name 子语句,则可以将目标表的视图卸载DETACH 再装载 ATTACH
[TO[db.]name]
[ENGINE = engine] -- 必须指定物化视图的 engine 用于数据存储
[POPULATE] -- 是否带入历史数据(不推荐),需要历史数据自己进行insert即可。且TO [db].[table]语法的时候,不得使用 POPULATE。
AS
SELECT ...
#建表语句
CREATE MATERIALIZED VIEW hits_mv
ENGINE=SummingMergeTree
PARTITION BY toYYYYMM(EventDate)
ORDER BY (EventDate, intHash32(UserID))
AS
SELECT
UserID,
EventDate,
count(URL) as ClickCount,
sum(Income) AS IncomeSum
FROM hits_test
WHERE EventDate >= '2014-03-20' #设置更新点,该时间点之前的数据可以另外通过
GROUP BY UserID,EventDate;
-- 导入增量数据只需要在原表进行插入即可。
-- 导入历史数据
INSERT INTO view_name
SELECT
...
物化视图创建好之后,若源表被写入新数据则物化视图也会同步更新。物化视图不支持同步删除,若源表的数据不存在(删除了)则物化视图的数据仍然保留。
POPULATE
若有 POPULATE 则在创建视图的过程会将源表已经存在的数据一并导入。若无 POPULATE 则物化视图在创建之后没有数据,只会在创建只有同步之后写入源表的数据
官方并不推荐使用 POPULATE,因为在创建物化视图的过程中同时写入的数据不能被插入物化视图。
物化视图是一种特殊的数据表,可以用 show tables 查看。
12. 参数聚合函数
12.1. 路径匹配
sequenceMatch(pattern)(timestamp, cond1, cond2)
匹配1,不匹配0sequenceCount(pattern)(timestamp, cond1, cond2)
计数
用法
-- 建表语句
create table action_log(
id UInt16,
ref_date DateTime,
action String
)engine=MergeTree()
partition by ref_date
order by ref_date;
-- 插入数据
insert into action_log
values (1,toDateTime('2021-03-19 14:00:00'),'点赞'),
(1,toDateTime('2021-03-19 14:01:00'),'投币'),
(1,toDateTime('2021-03-19 14:02:00'),'分享'),
(1,toDateTime('2021-03-19 14:03:00'),'转发'),
(1,toDateTime('2021-03-19 14:04:00'),'一键三连');
-- 2在1的后面紧挨着
SELECT sequenceMatch('(?1)(?2)')(ref_date , action = '点赞', action = '投币', action = '分享', action = '转发', action = '一键三连') as cnt FROM action_log
-- 2在1的后面紧挨着
-- 1和2中间的时间<=60s
SELECT sequenceMatch('(?1)(?t<=60)(?2)')(ref_date , action = '点赞', action = '投币', action = '分享', action = '转发', action = '一键三连') as cnt FROM action_log
-- 4在1的后面 中间隔着两个 两个.
SELECT sequenceMatch('(?1)..(?4)')(ref_date, action = '点赞', action = '投币', action = '分享', action = '转发', action = '一键三连') FROM action_log
-- 5在1的后面任意个 用.*表示
SELECT sequenceMatch('(?1).*(?5)')(ref_date, action = '点赞', action = '投币', action = '分享', action = '转发', action = '一键三连') FROM action_log
12.2. windowFunnel
搜索滑动时间窗中的事件链,并计算从链中发生的最大事件数。
该函数采用如下算法:
- 该函数搜索触发链中的第一个条件并将事件计数器设置为1。 这是滑动窗口启动的时刻。
如果来自链的事件在窗口内顺序发生,则计数器将递增。 如果事件序列中断,则计数器不再增加。
如果数据在不同的完成点具有多个事件链,则该函数将仅输出最长链的大小。
语法
windowFunnel(window, [mode, [mode, ... ]])(timestamp, cond1, cond2, ..., condN)
- window — 滑动窗户的大小,表示事件链中第一个事件和最后一个事件的最大间隔。 单位取决于timestamp。用表达式来表示则是:timestamp of cond1 <= timestamp of cond2 <= ... <= timestamp of condN <= timestamp of cond1 + window。
- mode - 这是一个可选的参数,可以设置一个或多个参数。
- 'strict_deduplication' - 如果事件链中出现相同的条件,则会停止进一步搜索。
- 'strict_order' - 不允许其他事件的介入。 例如:在A->B->D->C的情况下,它在D停止继续搜索A->B->C,最大事件数为2。
- 'strict_increase' - 事件链中的时间戳必须严格上升。
- timestamp — 包含时间戳的列。 数据类型支持: 日期, 日期时间 和其他无符号整数类型(请注意,即使时间戳支持 UInt64 类型,它的值也不能超过Int64最大值,即2^63-1)。
- cond — 事件链的约束条件。 UInt8 类型。
SELECT
level,user_count,conv_rate_percent
FROM (
SELECT
level,
uniqCombined(user_id) AS user_count,
neighbor(user_count, -1) AS prev_user_count,
if (prev_user_count = 0, -1, round(user_count / prev_user_count * 100, 3)) AS conv_rate_percent
FROM (
SELECT
user_id,
windowFunnel(900)(
ts_date_time,
event_type = 'shtLogon',
event_type = 'shtKkClick' AND column_type = 'homePage',
event_type = 'shtOpenGoodsDetail',
event_type = 'shtAddCart',
event_type = 'shtOrderDone'
) AS level
FROM (
SELECT ts_date,ts_date_time,event_type,column_type,user_id
FROM ods.analytics_access_log_all
WHERE ts_date >= '2020-06-30' AND ts_date <= '2020-07-02'
AND site_id IN (10266,10022,10339,10030)
)
GROUP BY user_id
)
WHERE level > 0
GROUP BY level
ORDER BY level ASC
);
漏斗分析
CREATE TABLE action
(
`uid` Int32,
`event_type` String,
`time` datetime
)
ENGINE = MergeTree()
PARTITION BY uid
ORDER BY xxHash32(uid)
SAMPLE BY xxHash32(uid)
SETTINGS index_granularity = 8192
insert into action values(1,'浏览','2020-01-02 11:00:00');
insert into action values(1,'点击','2020-01-02 11:10:00');
insert into action values(1,'下单','2020-01-02 11:20:00');
insert into action values(1,'支付','2020-01-02 11:30:00');
insert into action values(2,'下单','2020-01-02 11:00:00');
insert into action values(2,'支付','2020-01-02 11:10:00');
insert into action values(1,'浏览','2020-01-02 11:00:00');
insert into action values(3,'浏览','2020-01-02 11:20:00');
insert into action values(3,'点击','2020-01-02 12:00:00');
insert into action values(4,'浏览','2020-01-02 11:50:00');
insert into action values(4,'点击','2020-01-02 12:00:00');
insert into action values(5,'浏览','2020-01-02 11:50:00');
insert into action values(5,'点击','2020-01-02 12:00:00');
insert into action values(5,'下单','2020-01-02 11:10:00');
insert into action values(6,'浏览','2020-01-02 11:50:00');
insert into action values(6,'点击','2020-01-02 12:00:00');
insert into action values(6,'下单','2020-01-02 12:10:00');
SELECT
user_id,
windowFunnel(1800)(time, event_type = '浏览', event_type = '点击', event_type = '下单', event_type = '支付') AS level
FROM
(
SELECT
time,
event_type,
uid AS user_id
FROM action
)
GROUP BY user_id
SELECT
arrayJoin(range(5)) AS level, -- range(5) = [0,1,2,3,4]
arraySort( -- 升序排序
arrayMap( -- 将列上的数据转换成数组
x -> x,
groupArray(lv)
)
) as arr, -- 将列转换成数组并升序排序,方便下一层计算
arrayFilter( -- 过滤函数
(x,y) -> x >= y, -- 筛选条件,如果数字比level大或者等,则表示经过这一个level
arr, -- 上面整理好的数组
arrayWithConstant( -- level填充,方便上层函数计算。会生成成内容为level,长度为arr的数组。
LENGTH (arr),
level
)
) as arr2, -- 生成了一个通过这一level的数组
length(arr2) as count -- 对arr2计数,计算通过这一层的个数(最终结果)。
FROM
(
SELECT
user_id,
windowFunnel(1800)(time, event_type = '浏览', event_type = '点击', event_type = '下单', event_type = '支付') AS lv
FROM
(
SELECT
time,
event_type,
uid AS user_id
FROM action
)
GROUP BY user_id
)
13. 其他
使用with totals语句后,sum统计行在DataGrip中并没有给出,但在clickhouse-client和DBeaver中都正常显示。应该是DataGrip的锅。
在查询指定日期的情况下,如果某个日期下没有数据,则这个日期不会显示,解决办法:
思路:生成一个包含全部时间的表,列如果没有则指定为默认值,然后和原来的表做全连接,最后使用聚合函数配合group by合并即可。
SELECT t, SUM(metric) as metric FROM ( SELECT arrayJoin( arrayMap( x -> toStartOfHour(addHours(toDateTime($from),x)), range(toUInt64( dateDiff('hour', toDateTime($from), toDateTime($to)) + 1))) ) as t, 0 as metric UNION ALL SELECT toStartOfHour(my_date) as t, COUNT(metric) FROM my_table WHERE t BETWEEN toDateTime($from) AND toDateTime($to) GROUP BY t ) GROUP BY t ORDER BY t
跨大版本升级的时候,最好使用
SELECT FROM remote
的方法。像从20.9.6.14升级到22.2.2.1,bitmap的位数原版本为32,后者为64,会报错Code: 1001. DB::Exception: Received from 192.168.1.1:9000 DB::Exception: std::runtime_error: failed alloc while reading. (STD_EXCEPTION)(vesrion 22.2.2.1)
。